昨天 Day 22,我們第一次停下新增元件的腳步,整理了 CUI 的 Component Contract。
目前的基本規則變得比較清楚:
Semantic HTML
↓
Component API
↓
Variant / Size
↓
Native & ARIA State
↓
data-cui-* Contract
↓
Design Tokens
今天可以繼續加元件了。
不過這次先不做新的大型 Pattern,也不碰 Dialog 這種 Focus Management 大魔王。
來補幾個看起來很小、實際上在系統裡很常出現的輔助元件:
Tooltip
Separator
Visually Hidden
其中今天的主角是:
Tooltip
因為 Tooltip 看起來可能只是:
滑鼠移過去
↓
跳出一個小泡泡
但只要開始考慮 Accessibility,問題馬上冒出來:
沒有滑鼠怎麼辦?
Tab Focus 時會出現嗎?
Tooltip 裡的文字 Screen Reader 知道嗎?
Esc 能不能關閉?
滑鼠移到 Tooltip 本身時會不會消失?
Touch Device 怎麼辦?
重要資訊可以只放 Tooltip 裡嗎?
小小一顆,事情意外地不少。
今天會補三個小型元件:
Tooltip
Separator
Visually Hidden
並整理它們各自的 Accessibility Responsibility:
Tooltip
→ 補充說明
Separator
→ 區隔內容
Visually Hidden
→ 視覺隱藏,但保留給 Assistive Technology
最後一樣補上 CUI Contract:
data-cui-slot
Tooltip 最常出現在 Icon-only Button。
例如:
┌─────┐
│ ✎ │
└─────┘
滑鼠移上去:
編輯
↓
┌──────────┐
│ 編輯 │
└──────────┘
┌───┐
│ ✎ │
└───┘
這可以幫助看得到畫面的使用者理解:
這個 Icon 是做什麼的?
但第一個問題也來了:
Screen Reader 不會因為有 Tooltip,就自動知道這是一個「編輯」按鈕。
假設我們有:
<Tooltip>
<TooltipTrigger>
<Button size="icon-md">
<PencilIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
編輯
</TooltipContent>
</Tooltip>
視覺使用者可能看得到:
✎
↓
編輯
但 Icon-only Button 本身仍然需要一個 Accessible Name。
例如:
<Button
size="icon-md"
aria-label="編輯申請"
>
<PencilIcon aria-hidden="true" />
</Button>
這樣:
Button Accessible Name
→ 編輯申請
Tooltip
→ 編輯
兩者看起來有點重複,但責任不同。
可以先這樣理解:
Accessible Name
→ 這個 Control 是什麼?
Tooltip
→ 給視覺使用者的補充提示
例如:
<Button aria-label="刪除申請">
<TrashIcon aria-hidden="true" />
</Button>
Tooltip:
刪除
Screen Reader 使用者不需要先「打開 Tooltip」,才能知道 Button 是什麼。
所以不要把:
Accessible Name
的責任全部丟給 Tooltip。
如果 Tooltip 只有:
:hover
那 Keyboard User 怎麼辦?
使用者可能是:
Tab
↓
Icon Button Focus
卻永遠看不到 Tooltip。
所以 Accessible Tooltip 至少要考慮:
Pointer Hover
+
Keyboard Focus
也就是:
Hover → Open
Focus → Open
這也是為什麼今天不打算自己從零寫:
const [open, setOpen] = useState(false)
然後自己處理:
mouseenter
mouseleave
focus
blur
keydown
這些 Interaction 已經不是單純 CSS Bubble 了。
目前 CUI 是:
shadcn/ui
+
Base UI
所以先讓既有 Primitive 處理底層 Interaction。
安裝:
npx shadcn@latest add tooltip
產生:
src/components/ui/tooltip.tsx
通常會包含:
TooltipProvider
Tooltip
TooltipTrigger
TooltipContent
使用:
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-md"
aria-label="編輯申請"
/>
}
>
<PencilIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
編輯
</TooltipContent>
</Tooltip>
實際 API 要以目前專案使用的 Base UI 版本為準。
CUI 不需要自己重新實作 Tooltip 的整套 Interaction。
這也是使用 Headless Primitive 的價值。
Tooltip 看起來很簡單,但底層其實需要處理:
Open / Close
Hover
Focus
Delay
Position
Escape
ARIA Relationship
Pointer Interaction
如果每個 Design System 都自己:
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
很容易只完成:
Mouse User ✓
其他使用方式全部漏掉。
所以 CUI 的工作不是:
重寫一套 Tooltip Engine。
而是:
在可靠 Primitive 上建立自己的 Styling、Contract 和 Usage Rules。
跟前面的 Component 一樣,我們保留:
data-slot
再加入 CUI Public Contract。
例如:
data-cui-slot="tooltip-trigger"
以及:
data-cui-slot="tooltip-content"
如果 Root 本身沒有實際 DOM,就不一定需要硬塞:
data-cui-slot="tooltip"
昨天 Day 22 才整理過:
不要為了 Contract 而建立沒有用途的 Attribute。
所以 Contract 應該跟真正輸出的 DOM Structure 對應。
Tooltip 最適合:
短
簡單
非互動
補充性
例如:
編輯
刪除
複製連結
下載
更多選項
不適合:
一大段說明文字
表單
按鈕
連結
複雜操作
如果內容開始變成:
Tooltip
├── 說明
├── Link
└── Button
那它大概已經不是 Tooltip 了。
可能應該考慮:
Popover
這是今天最重要的規則之一。
假設表單:
身分證字號 ⓘ
所有輸入規則都只放在 Tooltip:
請輸入 10 碼身分證字號,
第一碼必須為英文字母……
這就有問題。
因為使用者可能:
沒發現 Tooltip
無法 Hover
使用 Touch Device
放大畫面後沒有注意 Icon
必要資訊應該直接存在頁面裡。
例如:
<FieldDescription>
請輸入 10 碼身分證字號。
</FieldDescription>
Tooltip 可以補充:
為什麼需要這項資料?
而不是承擔:
沒有看 Tooltip 就無法完成任務
的資訊。
例如:
[ 🔍 ]
然後只有 Hover 才顯示:
搜尋
Button 本身還是需要:
aria-label="搜尋"
同樣:
Input 沒有 Label
+
Tooltip 顯示「關鍵字」
也不能算有 Label。
所以:
Tooltip
≠ Label
Tooltip
≠ Accessible Name
Tooltip
≠ Error Message
Tooltip
≠ Required Instruction
Tooltip 是:
Supplementary Information
今天實作完成後,可以直接不用滑鼠。
按:
Tab
移到 Tooltip Trigger。
檢查:
Focus 看得到嗎?
Tooltip 有沒有出現?
Accessible Name 是否正確?
再按:
Escape
確認 Tooltip 能正確關閉,而 Focus 不會莫名消失。
接著:
Shift + Tab
離開 Trigger。
Tooltip 也應該正常消失。
滑鼠測試也不是只有:
移上去有沒有出現
還要注意:
Trigger → Tooltip
之間的移動。
如果使用者想把 Pointer 移到 Tooltip 文字附近,它不應該在 Pointer 剛離開 Trigger 的瞬間:
啪!
消失
尤其對:
Low Vision
Motor Impairment
Magnification User
可能造成使用困難。
這也是不自己手刻 Tooltip Interaction 的另一個原因。
Touch Device 沒有真正的:
Hover
所以更不能讓重要功能依賴:
「Hover 就看得到。」
Tooltip 在 Touch Device 上的行為可能依 Primitive 與 Interaction Design 不同。
但我們至少可以確保:
沒有 Tooltip
↓
Control 仍然可以理解、可以操作
這才是比較穩定的設計。
第二顆元件簡單很多:
Separator
例如:
帳號設定
────────────
通知設定
安裝:
npx shadcn@latest add separator
Separator 可以有:
horizontal
vertical
例如:
<Separator />
或者:
<Separator orientation="vertical" />
如果純粹只是 Decorative:
視覺上分隔
它不一定需要出現在 Accessibility Tree。
但如果這條 Separator 本身代表:
內容群組之間具有語意上的分隔
就可能保留 Separator Semantic。
因此要分清楚:
Visual Decoration
和:
Semantic Separation
不是每一條:
border-top
都需要:
role="separator"
這顆相對單純:
data-cui-slot="separator"
如果 orientation 是 Public Styling Contract,也可以依 Primitive 實際輸出的:
data-orientation
處理。
昨天才整理過:
如果 Primitive / ARIA 已經提供狀態,不要急著複製成另一套
data-cui-*。
所以不一定需要:
data-cui-orientation="horizontal"
如果既有:
data-orientation="horizontal"
已經足夠。
最後這顆甚至可能:
完全看不到。
🤣
但 Accessibility UI Kit 很值得有。
Visually Hidden 的目的:
Visual
→ 看不到
Assistive Technology
→ 還是讀得到
我們前面其實已經使用過類似概念:
<span className="sr-only">
申請紀錄載入中
</span>
以及:
<TableCaption className="sr-only">
申請紀錄:包含姓名、申請項目、審核狀態與更新時間
</TableCaption>
這些其實就是:
Visually Hidden Content。
display: none?因為:
display: none;
通常代表:
Visual User
→ 看不到
Screen Reader
→ 也不讀
而 Visually Hidden 要的是:
Visual User
→ 看不到
Screen Reader
→ 可以讀
所以不能只是:
.hidden {
display: none;
}
sr-only 已經可以用了,還需要 Component 嗎?這是一個很合理的問題。
Tailwind 已經有:
<span className="sr-only">
那為什麼還要:
<VisuallyHidden>
?
其實兩種都可以。
如果只是偶爾:
<span className="sr-only">
載入中
</span>
sr-only 已經非常清楚。
但如果 CUI 希望把:
Visually Hidden
正式定義成 Design System Pattern,就可以包成 Component。
例如:
<VisuallyHidden>
開啟導覽選單
</VisuallyHidden>
這讓開發者不用記:
到底是 sr-only?
還是 hidden?
還是 opacity-0?
opacity: 0 也不是 Visually Hidden這也很容易搞混。
opacity: 0;
只是:
看不見。
元素可能還是:
佔空間
可以 Focus
可以點擊
存在 Accessibility Tree
所以不要拿:
opacity: 0
當成通用的 Accessibility Hidden Solution。
不同的「隱藏」其實有不同目的:
display: none
→ 所有人都不要看到
aria-hidden="true"
→ Assistive Technology 不需要
sr-only / Visually Hidden
→ 視覺隱藏,但保留給 Assistive Technology
opacity: 0
→ 只是透明
這幾個不能互換。
第一種就是:
Icon-only Button
例如:
<Button size="icon-md">
<SearchIcon aria-hidden="true" />
<VisuallyHidden>
搜尋
</VisuallyHidden>
</Button>
這樣 Button 的 Accessible Name 可以來自文字內容:
搜尋
而不是:
aria-label="搜尋"
兩種方式都有適用情境。
aria-label 還是 Visually Hidden?例如:
<Button aria-label="關閉">
<XIcon aria-hidden="true" />
</Button>
很乾淨。
另一種:
<Button>
<XIcon aria-hidden="true" />
<span className="sr-only">
關閉
</span>
</Button>
也可以。
兩者都能提供 Accessible Name。
目前 CUI 不需要規定:
全世界只能選其中一種。
但如果文字本身適合成為 DOM Content,我通常會偏好:
Real Text
因為它更容易:
翻譯
測試
搜尋
維護
而 aria-label 則很適合某些真的只有 Icon 的簡單 Control。
例如一顆 Icon Button:
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-md"
/>
}
>
<PencilIcon aria-hidden="true" />
<VisuallyHidden>
編輯申請
</VisuallyHidden>
</TooltipTrigger>
<TooltipContent>
編輯
</TooltipContent>
</Tooltip>
現在:
Visual User
→ Icon + Tooltip
Screen Reader
→ 編輯申請,按鈕
兩邊都不依賴對方才能理解 Control。
如果已經:
aria-label="編輯申請"
又:
<VisuallyHidden>
編輯申請
</VisuallyHidden>
就沒有必要兩套都加。
同樣也不要:
aria-label
+
aria-labelledby
+
Visually Hidden
+
Tooltip
全部塞上去,只因為:
Accessibility 越多越好!
不是這樣 😂
Accessibility Attribute 不是 Buff 疊層。
目標是:
用最簡單、正確的方式提供清楚語意。
今天三顆元件整理一下:
Tooltip
├── tooltip-trigger
└── tooltip-content
Separator
└── separator
Visually Hidden
└── visually-hidden
如果我們決定讓 Visually Hidden 成為正式 CUI Component,也可以:
data-cui-slot="visually-hidden"
但同樣要問:
Legacy CSS 真的需要這個 Hook 嗎?
如果只是:
一組固定的 visually-hidden CSS
它可能有價值。
如果沒有實際用途,也不用為了「每顆都有」硬加。
這就是昨天 Component Contract Audit 後開始建立的判斷方式。
這題就開始有趣了。
React:
<Tooltip>
...
</Tooltip>
有 Base UI 幫忙處理 Behavior。
但 Legacy:
<button
data-cui-slot="tooltip-trigger"
aria-describedby="edit-tooltip"
>
編輯
</button>
<div
id="edit-tooltip"
data-cui-slot="tooltip-content"
role="tooltip"
>
編輯申請
</div>
光有 HTML + CSS 還不夠。
還需要處理:
Open
Close
Hover
Focus
Escape
Position
也就是:
React
→ Base UI Behavior
Legacy
→ CUI Vanilla JS Adapter
這就是未來:
cui.js
可能開始有存在價值的地方。
例如:
Button
Badge
Alert
Table
Separator
大部分可能:
HTML + CSS
就夠了。
但:
Tooltip
Dialog
Select
Dropdown Menu
這些有 Interaction 的 Component:
HTML + CSS + Behavior
才可能需要:
cui.js
所以未來 CDN Build 很可能不是:
所有元件都靠 JavaScript
而是:
Static Components
→ CSS
Interactive Components
→ CSS + JS
這也會是 Day 27 做 CDN 時要真正解決的問題。
Tooltip:
✓ Mouse Hover 可以開啟
✓ Keyboard Focus 可以開啟
✓ Escape 可以關閉
✓ Trigger 本身有 Accessible Name
✓ Icon 不製造多餘名稱
✓ 不把必要資訊只放 Tooltip
Separator:
✓ Decorative Separator 不製造多餘語意
✓ Semantic Separator 使用正確角色
✓ Orientation 正確
Visually Hidden:
✓ 視覺上不可見
✓ Assistive Technology 仍可取得內容
✓ 不使用 display: none 取代
✓ 不產生重複 Accessible Name
今天特別值得完全把滑鼠放開。
只使用:
Tab
Shift + Tab
Enter
Space
Escape
測 Tooltip。
流程:
Tab
↓
Focus Icon Button
↓
Tooltip 出現
Escape
↓
Tooltip 關閉
Shift + Tab
↓
Focus 離開
再確認:
Focus Indicator
一直都存在。
Tooltip 不應該把 Focus 搶走。
今天表面上加入:
Tooltip
Separator
Visually Hidden
但其實分別代表三種很不一樣的 Accessibility 問題:
Tooltip
→ Interaction + Supplementary Information
Separator
→ Visual vs Semantic Structure
Visually Hidden
→ Visual Tree vs Accessibility Tree
尤其 Tooltip 讓我開始碰到:
Focus
Hover
Escape
Pointer
Touch
Accessible Name
這些問題。
也就是:
我們開始從 Static Component,往真正的 Interactive Component 前進了。
完成後:
npm run build
npm run lint
npm run check:contrast
再:
git status
如果今天建立獨立 Branch:
git switch -c feat/utility-components
完成後:
git add .
git diff --staged
Commit 可以:
git commit -m "feat: add accessible utility components"
今天補上的元件很小:
Tooltip
Separator
Visually Hidden
但它們讓 CUI 的 Accessibility 範圍又往外走了一點。
以前比較多是:
Semantic HTML
Color Contrast
Form Label
Validation
Table Structure
Data State
現在開始進入:
Hover
Focus
Escape
Accessible Name
Accessibility Tree
Interactive Behavior
而今天我最想留下的一句話是:
Tooltip 是補充資訊,不是資訊的唯一入口。
一個 Icon Button 即使 Tooltip 完全沒有出現:
Screen Reader
Keyboard
Touch
使用者還是應該知道:
這顆按鈕是做什麼的。
這才是 Tooltip 在 Accessible UI 裡比較健康的位置。
今天的 Tooltip 已經讓我們稍微碰到:
Open
Close
Focus
Escape
明天直接把難度往上拉。
來做:
Dialog 看起來只是:
按 Button
↓
跳出 Modal
但 Accessibility 問題會一次全部出現:
Dialog 打開後 Focus 去哪?
Tab 能不能跑到背景?
Escape 要不要關閉?
關閉後 Focus 回哪?
Dialog 要怎麼取得 Accessible Name?
背景內容要怎麼處理?
Alert Dialog 又跟普通 Dialog 有什麼不同?
如果 Tooltip 是 Interactive Component 的入門,
那 Dialog 就是:
Focus Management 正式登場。
Day 24:
Dialog:打開一個視窗之後,Focus 到底該去哪?